iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

ERP 架構師筆記:定義驅動的框架設計系列 第 17

Day 17:Connector 與呼叫端的職責收斂

  • 分享至 

  • xImage
  •  

Day 17:Connector 與呼叫端的職責收斂

昨天談的是一次呼叫送進伺服端之後怎麼被處理。同一份合約在呼叫端這一側也得有人負責:有的呼叫端連的是網路另一頭的伺服器,有的跑在後端邏輯的同一個行程裡,兩邊要呼叫的卻是同一支方法。

中間那些跟業務無關的事如果留給每一個前端各自處理,有幾個前端就會有幾種寫法,而且同一個錯會在每一個前端各自發生一次。框架的答案是在呼叫端也放一層 Connector,所有前端都經過它。

本篇說明:

  1. 呼叫端要處理的有哪幾件事,為什麼收在同一個型別裡
  2. 近端與遠端連線怎麼變成同一個呼叫,真正的差別在哪裡
  3. 哪些狀態屬於一個登入的使用者,哪些屬於整個應用

一、呼叫端的職責,不只是送出去

一次存檔從畫面出發、到資料庫為止,中間經過的層是固定的:

呼叫端
  畫面
    ↓
  DataObject:框架提供的 ViewModel,包住 DataSet 供畫面繫結(Day 2)
    ↓
  Connector(本篇)
    ↓  一次 JSON-RPC 呼叫

伺服端
  API 派發:method 切成 ProgId 與 action(Day 16)
    ↓
  BO:ProgId 從型別註冊表換來的那一個(Day 15)
    ↓
  Repository:依 FormSchema 現組增修刪查的語句(Day 5)
    ↓
  資料庫

圖上那一層 Connector,是呼叫端這一側處理 JSON-RPC 呼叫的角色:一次呼叫怎麼組成請求、怎麼送出去、回應怎麼還原,都由它負責,而前端不必知道另一端是網路另一頭還是同一個行程。

畫面那一層想做的事只有一句「把這張表單存回去」。要讓這句話真的成立,中間有一串與這張表單無關的事,而且每一件都必須做對。

這一件事 收斂之後由誰做 不收斂的話由誰做
定址 ProgId 與 action 接成 method 每個呼叫點自己拼字串
身分 令牌與 API 金鑰放進該放的位置 每次呼叫自己組標頭
傳輸 依模式挑一條路送出並收回 每個前端各自接一次
編碼 決定這一次要不要編碼與加密 每個前端各實作一次
時區 出去換成 UTC、回來換回使用者的時區 每個畫面各換一次,還要記得換
回傳 API payload 裡的內容轉成呼叫端宣告的型別 自己反序列化
錯誤 錯誤欄位翻回一個例外 每個呼叫點自己判錯誤碼

七件事全部收在 ApiConnectorExecuteAsync 這一條路上。拿掉追蹤與例外包裝之後,它的骨架是這樣:

protected async Task<T> ExecuteAsync<T>(string progId, string action, object value, PayloadFormat format)
{
    var timeZoneId = UserTimeZoneId;
    T result;
    using (PayloadZoneConverter.ToUtc(value, timeZoneId))
    {
        var (request, actualFormat) = PrepareRequest(progId, action, value, format);
        var response = await this.Provider.ExecuteAsync(request);
        result = FinalizeResponse<T>(response, actualFormat);
    }
    PayloadZoneConverter.ToUserZone(result, timeZoneId);
    return result;
}

PrepareRequest 組出來的那份請求,Method 就是 $"{progId}.{action}",也就是昨天談過的兩段式 method,而切它的那一段在伺服端。兩端的 JSON-RPC payload 是同一份程式碼組出來的,所以昨天那四處與規格的距離,框架自己的呼叫端一處都碰不到。FinalizeResponse 是把錯誤欄位翻回例外的地方,每個前端因此不必各自認得錯誤碼;那些碼各自是什麼意思,是明天的題目。

這七件事不只是一份清單,它們的先後是固定的。時區要在編碼之前換完,編碼之後 params 那一格裡已經是位元組;回程的解碼要在轉成型別之前。

其中一對是刻意排的:錯誤在解碼之前就判讀了,因為錯誤欄位掛在 JSON-RPC payload 本身、不在被編碼的那一格裡,一次失敗的呼叫不必先解一次密才知道自己失敗了。伺服端那條管線也有一處這樣刻意排的順序,呼叫端這一條短得多,道理是同一個。

昨天那條受理分界在呼叫端也有另一面:受理之前的失敗回的是 HTTP 狀態碼、受理之後走錯誤欄位,而呼叫端送出請求的那一句會檢查狀態碼,於是兩種失敗在這裡是兩個不同的 catch。

三個子類,三種取得 ProgId 的方式

基底類別只管上面那條路,ProgId 從哪裡來由子類決定。

型別 ProgId 從哪來 負責哪一類呼叫
ApiConnector 呼叫時傳入 抽象基底,不直接使用
SystemApiConnector 固定 System 系統層級:登入、進出公司、取定義
LogApiConnector 固定 AuditLog 系統層級:軌跡查詢
FormApiConnector 建構子帶進來 表單層級:任何一張表單

兩個固定值都是 Day 15 那份型別註冊表裡的保留字。保留字在伺服端是註冊表的鍵,在呼叫端則是一個不需要參數的建構子,同一個決定的兩面。

表單層級的六個 action,在 FormApiConnector 上各有一支對應的方法:GetListAsyncGetLookupAsyncGetNewDataAsyncGetDataAsyncSaveAsyncDeleteAsync。昨天說八張表單與上千張表單都是那六個 action,這裡是它在呼叫端的形狀:上千張表單共用同一個型別,差別只在建構時給的那個字串。

二、同一個呼叫,近端與遠端

一套 ERP 的呼叫端不會只有一種部署形狀,桌面、瀏覽器與伺服端自己的背景服務都算。把它們分成兩類的是與後端邏輯之間隔著什麼:

  • 遠端:使用者手上的前端發出呼叫,過一個網路到伺服端,再由伺服端走到 BO
  • 近端:背景服務要走同一份合約時,中間沒有網路,同一個行程裡就走到 BO

框架的處理是把「怎麼送出去」抽成一個介面,而這個介面只有一支方法:Task<JsonRpcResponse> ExecuteAsync(JsonRpcRequest request)。兩個實作。遠端那一支(RemoteApiProvider)把請求轉成 JSON、貼上兩個標頭、POST 出去、把回來的字串還原:

var headers = new NameValueCollection
{
    { ApiHeaders.ApiKey, ApiClientInfo.ApiKey },
    { ApiHeaders.Authorization, $"Bearer {AccessToken}" }
};
string json = await HttpUtilities.PostAsync(Endpoint, request.ToJson(), headers);
return JsonCodec.Deserialize<JsonRpcResponse>(json)!;

送出用的 HTTP 用戶端不是每次新建,依「協定加主機加通訊埠」共用一份;瀏覽器環境另走一條,因為那裡的連線集區由瀏覽器自己管。

近端那一支(LocalApiProvider)從行程內解析出昨天那個派發器,把令牌設上去,直接呼叫:

executor.AccessToken = AccessToken;
executor.IsLocalCall = true;
return await executor.ExecuteAsync(request);

Day 13 說 BO 的建構子第四個參數是「是否為同行程呼叫」,那個旗標的兩端在這裡才接得起來:近端這一支明確設成 true,走 HTTP 進來的那一條維持預設的 false,中間一路傳到工廠建 BO 的那一行。伺服端有幾支只接受同行程呼叫的維護方法,判準就是它(語意屬 Day 23)。

近端呼叫預設連編碼與加密都不做,格式定成 Plain,那一整段不執行。資料從頭到尾沒有離開過記憶體,那一段的成本在同一個行程裡換不到任何東西。

啟用偵錯模式時,近端呼叫照樣走完整條編碼與加密,於是開發時想確認自己那些資料序列化得正不正確,在同一個行程裡就驗得完,不必為此架一台伺服器。

走哪一條由建構子決定

ConnectType 這個列舉記的是決定,實際生效的是用了哪一個建構子:

protected ApiConnector(Guid accessToken, ApiSessionContext session)                    // 沒有位址,近端
protected ApiConnector(string endpoint, Guid accessToken, ApiSessionContext session)   // 有位址,遠端

位址從何而來、要不要走遠端,在更前面就決定了。ApiConnectValidator 拿到設定的位址,是本機路徑就當近端、是網址就先探一次連通性再打一次 PingSupportedConnectTypes 是應用自己的宣告,一個只允許遠端的應用拿到本機路徑會在這裡被擋下,而不是等到第一次呼叫失敗。

少了序列化,就少了一道免費的邊界

兩條路的差別不只是「有沒有走網路」。

遠端 近端
另一端是誰 一個 HTTP 端點 同一個行程裡的派發器
序列化 JSON 進、JSON 出 完全沒有
編碼與加密 依這一次的要求 預設整段跳過
令牌怎麼帶 Authorization 標頭 派發器上的一個屬性
送出的物件 對方拿到的是複本 對方拿到的是同一個物件

最後一列才是真正要處理的那一件。走網路時,序列化順手做出一份複本,呼叫端後續怎麼用自己那一份都不影響對方。同行程呼叫沒有這道手續,於是遠端免費得到的東西,近端要手工補回來。

框架補的方式是在時區轉換那一段換掉、用完換回去(PayloadZoneConverter):

case SaveRequest request when request.DataSet != null:
{
    var original = request.DataSet;
    request.DataSet = DateTimeZoneConverter.UserToUtc(original, timeZoneId);
    return new PayloadSwap(() => request.DataSet = original);
}

換上去的是一份新的資料,原本那一份在呼叫結束時放回去。少了這一步,呼叫端手上的資料會被換成 UTC 那一份,然後拿去重畫畫面。回程同理,回應裡的資料是複製一份出來改,不是就地改,因為同行程時那個物件是伺服端的。

兩條路要讓呼叫端看不出差別,代價就是這類「其中一條本來就有、另一條要自己造」的細節。少做一件,症狀不是報錯,是畫面上的值不對。

要對兩條路都成立的事,只能放在它們唯一都會經過的地方,也就是 Connector。時區換算就是這樣掛上去的,換算本身是第七章的題目。

三、什麼屬於一個使用者,什麼屬於整個應用

Connector 用到的那些狀態要放在哪裡,答案取決於一個行程裡有幾個使用者。

桌面應用一個行程只有一個使用者,登入拿到的傳輸金鑰與時區放在靜態屬性上完全正確,也最省事。伺服器渲染的網頁前端不是:同一個行程同時服務多條連線、每一條各自登入,而靜態屬性只有一組。後登入的人會蓋掉前一個人的金鑰,被蓋掉的那些人接下來每一次呼叫都用別人的金鑰加密,直到重新登入為止。

框架因此把「屬於一個登入使用者」的狀態收成一個型別 ApiSessionContext,裡面只有兩個值:這條連線的傳輸金鑰,和這個使用者的時區。

判準比機制本身更通用:

這個值的擁有者是 例子 該放哪裡
這個應用本身 API 金鑰、服務位址 整個行程一份
一個登入的使用者 傳輸金鑰、時區 逐個 session 一份
這一次呼叫 存取令牌 建構子參數,綁在那個 Connector 上

應用那一列是容易做錯的方向。API 金鑰識別的是「哪一個應用在呼叫」而不是「誰在呼叫」,每條連線的值都一樣,把它也切成逐 session 一份不會比較安全,只是把擁有者認錯了。

令牌那一列是第三種擁有者:它既不是應用層的,也不是掛在 session 狀態上的,而是每一個 Connector 建構時就帶進去、之後不能換。要換身分就換一個 Connector。遠端路徑把它變成 Bearer 標頭、近端路徑把它設在派發器上,呼叫端不需要知道是哪一種。

令牌在呼叫端住在哪裡

兩個前端家族的答案不一樣,源頭就是上面那個「一個行程裡有幾個使用者」。

桌面家族 伺服器渲染家族
令牌住在哪 一個靜態欄位 一個逐連線的元件,往下層層傳遞
session 狀態 整個行程共用一份 逐連線各一份
Connector 怎麼來 靜態方法現建 注入一個逐連線的工廠
換令牌 連帶丟掉快取的 Connector 與定義快取 重新渲染,下層自己重取

換令牌那一列是前面那條規則的直接後果:令牌在建構的時候就綁定、之後換不了,所以換一次令牌等於把手上那個 Connector 整個換掉。

回到 Northwind

案例有四個前端:桌面、瀏覽器、iOS、Android。四個都跑同一個 Avalonia UI 專案,各自只有一段開機程式碼,而那段程式碼裡與本篇有關的只有三件事:宣告只支援遠端、指定位址與金鑰要存到哪、把出廠金鑰當第一次執行的種子。UI 專案的專案檔裡沒有 Bee.Api.Client 這一筆,它要用的 Connector 由框架的 UI 組件建好之後交給它。

四個前端共用一個 UI 專案,這件事之所以成立,一部分就是因為呼叫端這一層把差異吃掉了。唯一走出不同路徑的是瀏覽器那一頭:那個執行環境產不出 RSA 金鑰對,於是登入時的金鑰交換整段跳過,後續要求加密的呼叫由 Connector 自動降一級。呼叫的程式碼一個字都不用改。

案例示範不出來的有兩件。四個前端全部宣告只支援遠端,所以同行程那條路徑在這套應用裡一次都沒有被走到,它活在測試與示範程式裡。案例也沒有伺服器渲染的前端,所以「一個行程好幾個使用者」那條線在這裡連發生的條件都沒有。

小結

把呼叫端該做的事收成一層,換到的是「可能寫錯的地方」從前端的數量變成一個。

  • 定址、身分、傳輸、編碼、時區、回傳型別、錯誤,七件事收在同一條路上,兩端的 JSON-RPC payload 因此是同一份程式碼組出來的
  • 近端與遠端由建構子多載決定,差別不只是有沒有走網路,還包含遠端免費附贈而近端必須自己造出來的那道複本邊界
  • 狀態該不該逐 session 一份,看的是這個值的擁有者是一個使用者還是這個應用
  • 兩條路唯一都會經過的點是 Connector,所以要對兩者都成立的事只能放在那裡

最該帶走的是第三點,因為這條線畫錯的兩個方向不對稱。把應用層的值切成逐 session 一份,代價只是多幾份一模一樣的副本;把使用者的值放成整個行程共用,代價是另一個人的資料。前者浪費,後者是事故,而兩者在單人測試下的表現完全相同。

明天談呼叫失敗的那一半:一個業務規則擋下來的存檔,要用什麼形式讓呼叫端知道,而哪些訊息可以原樣送到使用者面前。


本系列同步發表於 HackMD,完整目錄


上一篇
Day 16:JSON-RPC 2.0:協定選型與請求管線
下一篇
Day 18:錯誤契約與使用者可見訊息
系列文
ERP 架構師筆記:定義驅動的框架設計26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言